Skip to content

feat: add an accessible dismissal path for modal sheets - #56

Open
falcondpr wants to merge 30 commits into
software-mansion-labs:mainfrom
falcondpr:feat/accessible-sheet-dismissal
Open

falcondpr wants to merge 30 commits into
software-mansion-labs:mainfrom
falcondpr:feat/accessible-sheet-dismissal

Conversation

@falcondpr

@falcondpr falcondpr commented Jul 8, 2026 •

Copy link
Copy Markdown

Refs #52.

Problem

ModalBottomSheet has no accessible dismissal path:

  • On iOS the scrim is a plain UIControl that is never exposed to VoiceOver — sighted users can tap it to close the sheet, but VoiceOver users cannot reach it.
  • VoiceOver's escape gesture (two-finger Z scrub) does nothing, since accessibilityPerformEscape is not implemented on the host.
  • On Android the scrim is drawn directly on the host's canvas (drawScrim), so it does not exist in the accessibility tree at all, and no ACTION_DISMISS is surfaced — TalkBack has no dismiss affordance either.

Change

iOS (BottomSheetHostingView.swift)

  • The scrim becomes an accessibility element with a button trait and a "Dismiss" label, but only while activating it would actually dismiss the sheet: modal, non-programmatic close detent, scrim visible — the same condition that drives scrimDismissIndex. A scrim over a programmatic-only close detent stays out of the tree, since it is decorative rather than actionable. Exposure is recomputed in updateInteractionState(), alongside the existing isUserInteractionEnabled logic.
  • accessibilityPerformEscape is implemented on the hosting view. It reuses the exact scrim-tap dismissal path — factored into attemptScrimDismissal(), shared with handleScrimPress — and returns false when there is nothing to dismiss, so the gesture keeps bubbling to enclosing containers.
  • The scrim is now a UIControl subclass whose accessibilityActivate sends .touchUpInside directly. VoiceOver's default activation simulates a tap at the activation point, which is unreliable when the sheet overlaps that point mid-settle.

Android (BottomSheetHostView.kt)

  • The canvas-drawn scrim is exposed as a virtual accessibility node via ExploreByTouchHelper: button class, translatable "Dismiss" content description, bounds spanning from the host's top edge down to the sheet's current top, existing only under the same only-while-dismissible condition as iOS. Hover, key, and focus events are forwarded so both touch exploration and keyboard navigation reach it.
  • ACTION_DISMISS is surfaced on the sheet container so TalkBack's dismiss action works while focus is inside the sheet content — the Android counterpart of accessibilityPerformEscape. Both paths funnel into attemptScrimDismissal(), which mirrors the scrim-tap path (scrimDismissIndex → snapToIndex) and emits onIndexChange, so a controlled index stays in sync.
  • The virtual node's appearance/disappearance is reported via invalidateRoot() only on transitions; the check lives in updateInteractionState(), which runs on every frame of a settle.
  • Adds androidx.customview:customview (for ExploreByTouchHelper).

No JS or public-API changes.

Validation

Example app on React Native 0.85.3 (New Architecture, Hermes).

Android — physical Pixel 9:

  • With the "Basic modal" sheet open, the accessibility tree (uiautomator dump) contains the virtual scrim node: class="android.widget.Button", content-desc="Dismiss", clickable, focusable, bounds [0,0][1080,1689] — from the top of the screen down to the sheet's top edge.
  • After the sheet closes, the node is gone from the tree.
  • Touch behavior unchanged: scrim tap still dismisses; drags and snaps unchanged.

iOS — iPhone 17 simulator (iOS 26.4):

  • Builds and runs; scrim-tap dismissal, drags, and snaps unchanged.
  • Not yet verified with VoiceOver on a physical device (VoiceOver is not available on the simulator). I am trying to get a physical iOS device to verify the scrim exposure and the escape gesture; in the meantime, happy for anyone with device access to confirm.

Notes

  • External-keyboard ESC support (raised in the issue comments) is intentionally left as a follow-up.
  • The "Dismiss" label is hardcoded in English on iOS; if there is a preferred localization mechanism, I am happy to adopt it. On Android it is a string resource, so consumers can override it per locale.

iOS: expose the scrim to VoiceOver as a dismiss button while a
non-programmatic close detent exists, and implement
accessibilityPerformEscape on the hosting view so the escape gesture
dismisses the sheet through the exact scrim-tap path.

Android: expose the canvas-drawn scrim as a virtual dismiss button via
ExploreByTouchHelper, and surface ACTION_DISMISS on the sheet container
so TalkBack's dismiss action closes the sheet from focus within the
content.

Refs software-mansion-labs#52
@DanyKrk DanyKrk self-assigned this Sep 14, 2026
  Replace the canvas-drawn scrim and its virtual accessibility node with a
  real native View placed below the sheet container.

  ExploreByTouchHelper could not reliably combine the virtual scrim with
  the host's real descendants. Its accessibility provider exposed the
  scrim while making sheet content unreachable in parts of the TalkBack
  tree. Forwarding key events from the host to the helper also allowed it
  to consume Enter before the event reached a focused sheet descendant,
  including when no modal scrim was active.

  Using a real child gives the scrim standard Android accessibility,
  focus, keyboard, and traversal behavior. Expose it as a Dismiss button,
  limit its accessibility bounds to the area above the sheet, and place it
  after the sheet container in TalkBack traversal order. Route click,
  dismiss, Enter, Space, and DPAD Center through the existing scrim
  dismissal path so they emit onIndexChange without triggering
  onCloseRequest.

  Keep the scrim visually full-screen and animate only View.alpha. Change
  visibility and accessibility properties only when their effective state
  changes, and use INVISIBLE instead of GONE to avoid extra layout work.
  The host continues to own touch handling, while the scrim view always
  rejects touch events, preserving the existing gesture state machine.

  Keep React Native child counts and Fabric indices scoped to the sheet
  container despite the additional native child. Remove the host
  ExploreByTouchHelper integration, manual Canvas drawing, Paint state,
  and the direct androidx.customview dependency.

  Add regression coverage for the real accessibility tree, focused
  content key delivery, scrim semantics and bounds, confirm-key
  activation, touch routing, disabled states, and separation from
  Back/Escape close requests.
Portal close ownership previously followed registration order within each React root. That could route Back or Escape to a sheet below another active portal and allowed roots in the same host window to make conflicting ownership decisions.

Add a window-scoped presentation coordinator that tracks active portals, resolves their current React roots and native hierarchy paths, and selects a unique top presentation from observable drawing order. Reconcile ownership before drawing so z-order changes take effect without requiring sheet state or layout updates. When visual order cannot be proven, keep registration order as a deterministic close fallback without treating it as visual top.

Centralize presentation ownership so close-request routing and portal accessibility can consume the same Active and Top presentation decisions instead of deriving potentially conflicting owners independently.

Move active-presentation tracking out of close-request code so presentation lifecycle and ordering are independent of handlers and input policy. Close routing now consumes the shared assignment while preserving closing-through-settle ownership, handlerless top blocking, synchronous owner transfer, and predictive Back and Escape capture semantics.

Add coverage for multiple React roots in one window, independent windows, native drawing order, unknown order, hierarchy migration, stale entries, and observable Back and Escape routing.
Verify that the sheet-owned accessibility action commits the closed detent without emitting a close request when focus is inside sheet content.
@DanyKrk
DanyKrk force-pushed the feat/accessible-sheet-dismissal branch from 8decf8f to 59d8dad Compare September 20, 2026 10:29
Mask branches outside coordinator-selected React root paths and restore application importance safely.

Reconcile Fabric and Paper mount batches, preserve multiple retained paths, and cover the behavior with focused JVM tests.
Mount portal wrappers under one non-flattened native host so Android can derive a unique visual Top even when the provider is nested below a custom ViewGroup. This prevents the conservative unknown-order fallback from leaving lower active portals reachable to TalkBack. Add provider-topology and real-view accessibility regressions, and run the JS coverage in CI.
Keep a two-portal screen with distinct lower and upper focus targets plus open, closing, and settle logs. This gives maintainers a repeatable TalkBack path for verifying that the visual Top hides the lower portal until ownership transfers after settle.
@DanyKrk
DanyKrk force-pushed the feat/accessible-sheet-dismissal branch from 0996735 to a18c33a Compare September 21, 2026 18:51
DanyKrk and others added 3 commits September 22, 2026 09:47
…-dismissal

# Conflicts:
#	android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt
#	ios/BottomSheetComponentView.mm
#	src/BottomSheetProvider.tsx
@falcondpr

Copy link
Copy Markdown
Author

@DanyKrk heads-up: this PR had conflicts with main, so I merged main into the branch in f935346. It's a merge, not a rebase, so your commits are unchanged.

Three files had conflicts:

Checks:

  • typecheck, lint, test:js: pass
  • Android unit tests: 128/128 pass
  • iOS: the ReactNativeBottomSheet target builds. I couldn't run the test:ios:native XCTests locally because my machine doesn't have the required Ruby 4.0.5.

Let me know if you'd have resolved any of these differently.

@DanyKrk

DanyKrk commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator

@falcondpr Thanks for merging main into the PR—I’d keep all three conflict resolutions as-is, and the later CI run also passed all 35 iOS native tests.

…into feat/accessible-sheet-dismissal

# Conflicts:
#	ios/BottomSheetComponentView.mm

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ModalBottomSheet] The screen reader reaches the screen behind an open sheet Overlay dismiss with VoiceOver on iOS

2 participants